helper-based atomic-write cleanup; replace @unlink/@chmod#52
helper-based atomic-write cleanup; replace @unlink/@chmod#52mambax7 wants to merge 11 commits intoXOOPS:masterfrom
Conversation
Changes:
cp_functions.php:
+ xoops_remove_file_quietly($path, $context): new helper for
best-effort file removal. Skips non-existent paths, suppresses
the unlink warning via a SCOPED error_reporting() toggle (no @
operator) wrapped in try/finally, and re-checks file_exists()
after a failed unlink — only logging when the file is still
present so TOCTOU races resolve silently. Uses
xoops_file_label() for non-sensitive path labels in warnings.
- 9 @Unlink cleanup sites replaced with the helper.
- @chmod($tempFile, $perms) replaced with checked chmod() that
logs on failure but continues (content is already written).
modules/system/class/maintenance.php:
+ Explicit `require_once .../include/cp_functions.php` so
SystemMaintenance is self-sufficient regardless of which
caller (admin, install, modulesadmin, preferences) loads it.
- 6 atomic-write @Unlink cleanup sites replaced with the helper.
- @chmod replaced with checked + warn (mirrors cp_functions.php).
- cleanOrphanedAvatars(): @Unlink replaced with the helper plus
a path-traversal-safe resolution. Avatar rows store
'avatars/<filename>' (kernel/avatar.php and 14 admin/profile
writers), so basename() would have stripped the directory and
silently bypassed all orphan cleanup. The new form:
- normalises backslashes to '/' (Windows-historic data)
- strips leading slashes (defends against absolute paths)
- resolves via realpath() so '../' segments collapse
- confirms containment under realpath(XOOPS_UPLOAD_PATH)
with a trailing-separator boundary check
- confirms is_file() before removal.
The DB row cleanup runs unconditionally (file gone or not).
Intentionally retained:
- The 7 @rename(...) calls inside `if (!...)` checks. These are
the core atomic-move operations; the boolean return is already
detected and reported via trigger_error(). Removing the @ would
let PHP's native warning fire alongside our diagnostic, double-
reporting one event into display_errors output. Comment block
in each file documents the rationale.
|
Important Review skippedThis PR was authored by the user configured for CodeRabbit reviews. CodeRabbit does not review PRs authored by this user. It's recommended to use a dedicated user account to post CodeRabbit review feedback. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
WalkthroughAdds scoped quiet chmod/unlink helpers, applies them to atomic-write and guard-file flows, hardens avatar deletion with path normalization and containment checks, requires the helpers in SystemMaintenance, and adds unit tests covering CleanAvatar() variants. ChangesFile Handling Safety and Error Recovery
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~22 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. Comment |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #52 +/- ##
============================================
- Coverage 18.08% 18.07% -0.02%
- Complexity 7840 7854 +14
============================================
Files 665 665
Lines 42982 43133 +151
============================================
+ Hits 7775 7797 +22
- Misses 35207 35336 +129 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR reduces error-suppression usage in file cleanup paths by introducing a centralized “best-effort” delete helper and adopting it in atomic-write cleanup code, while also hardening orphaned-avatar deletion against path traversal.
Changes:
- Added
xoops_remove_file_quietly()incp_functions.phpand replaced multiple@unlinkcleanup sites with the helper. - Replaced
@chmodcalls with checkedchmod()that logs warnings on failure but continues. - Hardened
SystemMaintenance::CleanAvatar()orphan cleanup by normalizing and resolving avatar paths and enforcing containment underXOOPS_UPLOAD_PATH.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| htdocs/modules/system/class/maintenance.php | Requires cp_functions.php, adopts the new cleanup helper in atomic-write cleanup, and hardens orphaned avatar file removal with realpath() + containment checks. |
| htdocs/include/cp_functions.php | Introduces xoops_remove_file_quietly() and replaces multiple @unlink/@chmod usages in xoops_write_file_atomically(). |
…out of loop Address PR XOOPS#52 follow-up review (Copilot non-blocking comments): modules/system/class/maintenance.php (CleanAvatar): realpath(XOOPS_UPLOAD_PATH) and the trailing-separator prefix are constant for the whole run, so computing them per row in the while-loop is wasted filesystem work on installations with large orphaned-avatar tables. Hoisted outside the loop into \$uploadRoot and \$uploadRootPrefix; the in-loop check now uses str_starts_with against the cached prefix. tests/unit/htdocs/modules/system/SystemMaintenanceTest.php: Added six #[Test] cases covering the path-traversal-safe orphan cleanup logic introduced when @Unlink() was replaced by xoops_remove_file_quietly(): - cleanAvatarRemovesValidAvatarFileUnderUploadRoot — happy path - cleanAvatarSkipsTraversalPathButStillDeletesDbRow — ../ blocked - cleanAvatarSkipsAbsolutePathButStillDeletesDbRow — /etc/hosts - cleanAvatarHandlesMissingFileAndStillDeletesDbRow - cleanAvatarHandlesEmptyAvatarFileAndStillDeletesDbRow - cleanAvatarNormalisesBackslashesInAvatarFile Each test asserts exec() is called exactly twice (avatar DELETE + avatar_user_link cleanup) regardless of file removal outcome, so the 'DB rows deleted regardless of file outcome' invariant is codified rather than relying on inspection. Filesystem fixtures live in a unique scratch subdirectory under XOOPS_UPLOAD_PATH/avatars/_test_<pid>_<uniqid> with finally-block cleanup so the tests do not collide with each other or with anything else in the upload tree.
| /** | ||
| * Stub the database mock so $db->query() returns a sentinel result and | ||
| * $db->fetchArray() yields the supplied avatar rows once, then false. | ||
| * Returns the mock for further expectations. | ||
| */ | ||
| private function stubAvatarSweep($db, array $rows): void | ||
| { | ||
| $db->method('query')->willReturn('mock_result'); | ||
| $db->method('isResultSet')->willReturn(true); | ||
| $rows[] = false; // end-of-result sentinel | ||
| $db->method('fetchArray')->willReturnOnConsecutiveCalls(...$rows); | ||
| } |
CI run XOOPS#52 failed with 6 IncompatibleReturnValueException errors: Method exec may not return value of type int, its declared return type is \"bool\" XoopsMySQLDatabase::exec(string \$sql): bool returns true on success and false on failure (or false from PHP's mysqli_query for many DDL statements). The CleanAvatar() tests stubbed exec() with willReturn(1), which works under loose return-type checking but trips PHPUnit 11's strict return-type validation. Fix: all 6 exec() stubs use willReturn(true).
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@htdocs/modules/system/class/maintenance.php`:
- Line 200: The DELETE SQL concatenates $myrow['avatar_id'] directly into the
string; change the interpolation to cast the value to int before concatenation
to avoid injection/scan warnings—update the calls that use $this->db->exec(...)
with $this->db->prefix('avatar') and $myrow['avatar_id'] (the deletes at the
current line and the adjacent one around line 213) so the avatar_id is cast to
(int) (e.g., use (int)$myrow['avatar_id']) when building the WHERE clause.
In `@tests/unit/htdocs/modules/system/SystemMaintenanceTest.php`:
- Line 368: The mocks for XoopsDatabase::exec() use willReturn(1) which violates
exec()'s bool return type and causes PHPUnit IncompatibleReturnValueException;
locate every occurrence of $db->expects(...)->method('exec')->willReturn(1) in
the SystemMaintenanceTest cleanAvatar* tests and change willReturn(1) to
willReturn(true) (apply to all six instances where exec() is stubbed).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 3dc5b9f2-faa6-4f9e-b34c-1076d70a5758
📒 Files selected for processing (2)
htdocs/modules/system/class/maintenance.phptests/unit/htdocs/modules/system/SystemMaintenanceTest.php
| /** | ||
| * Stub the database mock so $db->query() returns a sentinel result and | ||
| * $db->fetchArray() yields the supplied avatar rows once, then false. | ||
| * Returns the mock for further expectations. | ||
| */ | ||
| private function stubAvatarSweep($db, array $rows): void | ||
| { | ||
| $db->method('query')->willReturn('mock_result'); | ||
| $db->method('isResultSet')->willReturn(true); | ||
| $rows[] = false; // end-of-result sentinel | ||
| $db->method('fetchArray')->willReturnOnConsecutiveCalls(...$rows); | ||
| } |
…st; (int) avatar_id Address PR XOOPS#52 follow-up review (Copilot, CodeRabbit Trivial): modules/system/class/maintenance.php (CleanAvatar): Cast $myrow['avatar_id'] to int once at the top of the loop body and use $avatarId in both DELETE statements (avatar row + the empty-avatar_file fast path). Defence-in-depth on the SQL concat even though the value is DB-origin; also silences SonarCloud's string-concatenation warning on these statements per project SQL hygiene. tests/unit/htdocs/modules/system/SystemMaintenanceTest.php: cleanAvatarSkipsTraversalPathButStillDeletesDbRow: The previous form created the fixture in sys_get_temp_dir() but used '../../<basename>' as the avatar_file. That string resolves from htdocs/uploads/ to a path under/near the project root — NOT the temp dir. realpath() returned false, the cleanup was skipped, and the test passed for the wrong reason: it never exercised the containment-prefix branch. New form: place the fixture at dirname(realpath(XOOPS_UPLOAD_PATH)) so '../<basename>' actually resolves to it. realpath() now succeeds, and the prefix check is the rejection mechanism. Added an explicit assertSame() sanity assertion comparing the resolved path to realpath($outside) so the test self-documents that it really exercises the containment branch. cleanAvatarSkipsAbsolutePathButStillDeletesDbRow: Replaced the hard-coded /etc/hosts assertion with a temp fixture via tempnam(). Windows CI runners don't have /etc/hosts at the same path, and even on POSIX systems the test relied on a pre-existing system file. tempnam() works on every supported OS. stubAvatarSweep(): Removed the misleading "Returns the mock for further expectations" docblock line. The method is `: void`; callers attach further expectations directly to the $db they passed in.
| $avatarId = (int) ($myrow['avatar_id'] ?? 0); | ||
| $avatarFile = ltrim(str_replace('\\', '/', (string) ($myrow['avatar_file'] ?? '')), '/'); | ||
| if ('' === $avatarFile) { | ||
| $result1 = $this->db->exec('DELETE FROM ' . $this->db->prefix('avatar') . ' WHERE avatar_id=' . $avatarId); | ||
| continue; | ||
| } | ||
| $avatarPath = realpath(XOOPS_UPLOAD_PATH . '/' . $avatarFile); | ||
| if ( | ||
| is_string($avatarPath) | ||
| && is_string($uploadRootPrefix) | ||
| && str_starts_with($avatarPath, $uploadRootPrefix) | ||
| && is_file($avatarPath) | ||
| ) { | ||
| xoops_remove_file_quietly($avatarPath, 'orphaned avatar'); | ||
| } |
Address PR XOOPS#52 follow-up review (Copilot, 3 actionable): cp_functions.php: + xoops_chmod_quietly($path, $perms, $context): new helper mirroring xoops_remove_file_quietly(). Suppresses chmod()'s native PHP warning via the same scoped error_reporting() toggle + try/finally and emits a single project-standard trigger_error on the boolean false return. Without this, a single chmod failure produced TWO log lines (PHP warning + trigger_error). - The chmod() + trigger_error block in xoops_write_file_atomically() replaced with the helper. modules/system/class/maintenance.php: - chmod() + trigger_error block in writeFileWithWarning() replaced with the same helper (already require_once'ing cp_functions.php). - CleanAvatar(): containment check narrowed from realpath(XOOPS_UPLOAD_PATH) to realpath(XOOPS_UPLOAD_PATH . '/avatars'). Custom avatars are stored as 'avatars/<filename>' (kernel/avatar.php and 14 admin/edituser writers), so the broad upload-root check would have allowed deletion of any file under uploads/ if avatar_file pointed elsewhere — legacy data, custom- module write, or accidental insertion. The narrow prefix is defence-in-depth: skip rather than silently delete unrelated uploads. Hoisted out of the loop so the realpath() resolution is computed once per sweep. tests/unit/htdocs/modules/system/SystemMaintenanceTest.php: + cleanAvatarSkipsNonAvatarsSubdirUnderUploadRoot: places a fixture at uploads/files/<unique>.doc and sets avatar_file to 'files/<unique>.doc'. Asserts the resolved path is under uploads/ (proving the test exercises the narrow-prefix branch, not the realpath-fail branch), runs CleanAvatar(), and verifies the fixture survives — codifying the narrowing as a regression test. No @ operators remain in either file's unlink/chmod paths; the seven @rename(...) calls inside `if (!...)` checks are still retained with the documented rationale.
…atar Address PR XOOPS#52 follow-up review (Copilot, line 316): placeFixtureAvatar() and the three inline file_put_contents() sites in cleanAvatar* tests previously ignored the return value. If the write failed (FS permissions, full disk, etc.) the happy-path tests could pass for the wrong reason — CleanAvatar() would find no file to delete and assertFileDoesNotExist() would succeed even though the cleanup never ran on a real fixture. All four fixture-creation sites now: - capture file_put_contents() return into $bytesWritten - assertNotFalse with a path-suffixed message for triage - assertSame(strlen(content), $bytesWritten) so partial writes are caught too, not just false-return failures Sites covered: - placeFixtureAvatar() (the one Copilot flagged) - cleanAvatarSkipsTraversalPathButStillDeletesDbRow - cleanAvatarSkipsAbsolutePathButStillDeletesDbRow - cleanAvatarSkipsNonAvatarsSubdirUnderUploadRoot
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
htdocs/modules/system/class/maintenance.php (1)
156-171:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winAdd
@throws \RuntimeExceptionto theCleanAvatar()docblock.The method throws a
\RuntimeException(line 169-173) but the PHPDoc is silent about it. As per coding guidelines, public methods must declare@throwswhen applicable.📝 Proposed docblock update
/** * CleanAvatar * * Clean up orphaned custom avatars left when a user is deleted. * * `@author` slider84 of Team FrXoops * + * `@throws` \RuntimeException If the avatar table query fails. * `@return` boolean */As per coding guidelines: "New public methods have PHPDoc with
@param,@return, and@throwstags".🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@htdocs/modules/system/class/maintenance.php` around lines 156 - 171, The CleanAvatar() method can throw a \RuntimeException but its PHPDoc lacks a `@throws` tag; update the method's docblock for CleanAvatar to include "@throws \RuntimeException" (with a brief description) so the public method documents the possible exception thrown by the database error branch (the RuntimeException raised around the $this->db->error() call).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@htdocs/include/cp_functions.php`:
- Around line 138-187: Both xoops_chmod_quietly and xoops_remove_file_quietly
risk using an uninitialized $ok if a user error handler throws inside the try;
to fix, initialize $ok = false immediately before each try block (in
xoops_chmod_quietly and xoops_remove_file_quietly) so the subsequent if (!$ok)
checks are safe even when unlink()/chmod() never return due to an exception;
keep the scoped error_reporting() and existing trigger_error() behavior
unchanged.
In `@htdocs/modules/system/class/maintenance.php`:
- Around line 210-227: The two assignments to $result1 in the CleanAvatar method
are unused; remove the variable capture and call $this->db->exec(...) directly
(or alternatively check its boolean result and log/return false on failure).
Locate the two calls to $this->db->exec(...) that currently assign to $result1
in class Maintenance::CleanAvatar and either (A) replace " $result1 =
$this->db->exec(...);" with "$this->db->exec(...);" to make the intent explicit,
or (B) change to "if (!$this->db->exec(...)) { /* log via
processLogger/db->error and return false; */ }" so failures are propagated.
---
Outside diff comments:
In `@htdocs/modules/system/class/maintenance.php`:
- Around line 156-171: The CleanAvatar() method can throw a \RuntimeException
but its PHPDoc lacks a `@throws` tag; update the method's docblock for CleanAvatar
to include "@throws \RuntimeException" (with a brief description) so the public
method documents the possible exception thrown by the database error branch (the
RuntimeException raised around the $this->db->error() call).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 663a51de-03a6-4402-a4f1-f40999eb6701
📒 Files selected for processing (3)
htdocs/include/cp_functions.phphtdocs/modules/system/class/maintenance.phptests/unit/htdocs/modules/system/SystemMaintenanceTest.php
Address PR XOOPS#52 follow-up review (CodeRabbit, 3 findings): cp_functions.php (xoops_chmod_quietly, xoops_remove_file_quietly): Initialise $ok = false before each try block. error_reporting(0) does NOT disable user-defined error handlers; only the native warning. A handler that throws (e.g. a strict ErrorException conversion that doesn't check error_reporting() & $errno) would propagate out of the try block before chmod()/unlink() returns, leaving $ok unset and triggering an "Undefined variable" warning on the subsequent if (!$ok) check. The defensive default makes the helpers safe under any error-handler shape. modules/system/class/maintenance.php (CleanAvatar): Drop the unused `$result1 = ` capture on both $this->db->exec() calls. PHPMD flagged this; the value was never read so the assignment was misleading (no error propagation either way). Removing makes the discard explicit. Also added `@throws \RuntimeException` to the CleanAvatar() docblock per project convention — the method does throw RuntimeException when the avatar query fails, but the PHPDoc was silent about it.
| // xoops_remove_file_quietly() lives in cp_functions.php; admin and install | ||
| // callers normally load it via cp_header.php / page_moduleinstaller.php, | ||
| // but require it explicitly here so SystemMaintenance is self-sufficient | ||
| // regardless of which context instantiates it. | ||
| require_once XOOPS_ROOT_PATH . '/include/cp_functions.php'; | ||
|
|
| function xoops_remove_file_quietly($path, $context = 'temporary') | ||
| { | ||
| if (!file_exists($path)) { | ||
| return; | ||
| } | ||
| // Initialise $ok defensively — see xoops_chmod_quietly() for the | ||
| // rationale (error_reporting(0) does not disable user-defined | ||
| // error handlers). | ||
| $ok = false; | ||
| $previousLevel = error_reporting(0); | ||
| try { | ||
| $ok = unlink($path); | ||
| } finally { | ||
| error_reporting($previousLevel); | ||
| } | ||
| if (!$ok && file_exists($path)) { | ||
| trigger_error( |
Address PR XOOPS#52 follow-up review (Copilot, 2 findings — CodeRabbit APPROVED): modules/system/class/maintenance.php: Add a `defined('XOOPS_ROOT_PATH') || exit()` guard before the require_once. The previous code commented out the guard but immediately uses XOOPS_ROOT_PATH in require_once, so direct access (or any include before bootstrap) would fail with an undefined-constant fatal that leaks server path detail in the error message. The guard makes direct access a clean exit. include/cp_functions.php (xoops_remove_file_quietly): file_exists() returns false for broken symlinks, so a dangling symlink would skip both the early-return guard AND the post-unlink existence check — the cleanup helper would never attempt removal and never warn that it was left behind. Add `|| is_link($path)` to both checks: unlink() can remove broken symlinks just fine, and the targets they point to are not what this helper is responsible for.
… guard Address PR XOOPS#52 follow-up review (Copilot, 4 findings): modules/system/class/maintenance.php: Direct-access guard (line 24): Switched from `if (!defined(...)) { exit(); }` to the project- standard one-liner: defined('XOOPS_ROOT_PATH') || exit('Restricted access'); Consistent with the other guards across the codebase, and the 'Restricted access' message is informative for log triage. CleanAvatar (symlink safety, lines 232-241): Previous code called realpath() on the full avatar path, then used the resolved path for both containment validation AND for the unlink. That's unsafe: realpath() follows symlinks, so an avatar entry that's a symlink within uploads/avatars/ would resolve to its target and the unlink would delete the wrong file (potentially another avatar) while leaving the symlink in place. New form keeps two paths: - $avatarCandidate = original path (used for is_file/is_link and the unlink itself — operates on the symlink, not the target) - $avatarParent = realpath(dirname($candidate)) (used ONLY for containment validation — resolves the parent directory's location without resolving the symlink itself) is_file() also gained an `|| is_link()` so symlinks within the avatars/ subtree are now eligible for cleanup. Smoke-tested: a symlink at uploads/avatars/<link> pointing to uploads/avatars/<real> is now correctly cleaned up (the symlink is removed, the target survives). PHPDoc (line 167): @return boolean → @return bool for consistency with the rest of the file.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
htdocs/modules/system/class/maintenance.php (1)
278-358: 🧹 Nitpick | 🔵 Trivial | 💤 Low value
writeFileWithWarning()atomic-write paths are consistent with the new helper conventions.Helper routing for temp-guard and backup-guard cleanup is uniform, the chmod demotion to non-fatal matches
xoops_write_file_atomically()semantics, and the$backupFile = nullinitialisation at Line 325 cleanly disambiguates the "no prior file" path from the "restoration needed" path at Lines 347–351.One minor observability nit (not blocking): when the initial replace fails but
@rename($backupFile, $filename)succeeds, the user sees onlyFailed to replace guard file, with no indication that the original was restored. Tracking it as something to consider if the message ever surfaces in support tickets.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@htdocs/modules/system/class/maintenance.php` around lines 278 - 358, The failure path in writeFileWithWarning() currently logs only "Failed to replace guard file" when rename(temp->target) fails even if the subsequent restore of the backup ($backupFile -> $filename) succeeds; update writeFileWithWarning() so that inside the if (!@rename($tempFile, $filename)) { ... if ($backupFile !== null) { if (!@rename($backupFile, $filename)) { ... } } } block you detect the return of `@rename`($backupFile, $filename) and emit an additional, clear log when the restore succeeds (e.g. a trigger_error/notice like "Restored original guard file: %s" using $label) so callers can observe that the original was restored; keep the existing error path when the restore fails.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@htdocs/modules/system/class/maintenance.php`:
- Around line 278-358: The failure path in writeFileWithWarning() currently logs
only "Failed to replace guard file" when rename(temp->target) fails even if the
subsequent restore of the backup ($backupFile -> $filename) succeeds; update
writeFileWithWarning() so that inside the if (!@rename($tempFile, $filename)) {
... if ($backupFile !== null) { if (!@rename($backupFile, $filename)) { ... } }
} block you detect the return of `@rename`($backupFile, $filename) and emit an
additional, clear log when the restore succeeds (e.g. a trigger_error/notice
like "Restored original guard file: %s" using $label) so callers can observe
that the original was restored; keep the existing error path when the restore
fails.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 1d53766d-6c3d-48ac-8b53-1513a1b1f78a
📒 Files selected for processing (2)
htdocs/include/cp_functions.phphtdocs/modules/system/class/maintenance.php
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
htdocs/modules/system/class/maintenance.php:248
- CleanAvatar() always returns true and ignores the return values from the DELETE exec() calls (including the final avatar_user_link cleanup, currently assigned to $result2 but never checked). Since callers use the boolean return to decide whether the avatar purge succeeded (e.g. system/admin/maintenance/main.php), failures here will be reported as success. Consider tracking exec() results and returning false when a delete fails (or removing the boolean return entirely if you don't want callers to rely on it).
$this->db->exec('DELETE FROM ' . $this->db->prefix('avatar') . ' WHERE avatar_id=' . $avatarId);
}
//clean any deleted users from avatar_user_link table
$result2 = $this->db->exec('DELETE FROM ' . $this->db->prefix('avatar_user_link') . ' WHERE user_id NOT IN (SELECT uid FROM ' . $this->db->prefix('users') . ')');
return true;
… writeFileWithWarning
modules/system/class/maintenance.php:
CleanAvatar() — return contract:
The method's @return bool was always returning true regardless
of whether any DELETE actually succeeded. If a row delete or
the final avatar_user_link cleanup failed, the orphaned row
was left behind and the caller had no way to know.
Track a $deleteOk flag across all three exec() sites
(empty-avatar_file branch, regular avatar-row branch, final
avatar_user_link cleanup) and return it. Existing CleanAvatar
tests stub exec() with willReturn(true) so $deleteOk stays
true and the return value is unchanged in the happy path.
writeFileWithWarning() — restore observability:
When the rename($tempFile, $filename) replace step fails but
the rename($backupFile, $filename) restore step succeeds, the
log previously said only "Failed to replace guard file: <X>",
leaving operators unsure whether manual recovery was needed.
Track $restoredBackup and fold the status into the existing
composite failure warning:
"Failed to replace guard file: <X> (original restored)"
when the restore worked, or the unsuffixed form (plus the
pre-existing "Failed to restore original guard file" line)
when both steps failed and manual intervention is needed.
No additional success warnings introduced.
| function xoops_chmod_quietly($path, $perms, $context = 'temp') | ||
| { | ||
| // Initialise $ok before the try block: error_reporting(0) does NOT | ||
| // disable user-defined error handlers, only the native warning. A | ||
| // naively written handler that always throws (without checking | ||
| // error_reporting() & $errno) would propagate out of the try block | ||
| // before chmod() returns, leaving $ok unset. Defensive default. | ||
| $ok = false; | ||
| $previousLevel = error_reporting(0); | ||
| try { | ||
| $ok = chmod($path, $perms); | ||
| } finally { | ||
| error_reporting($previousLevel); | ||
| } | ||
| if (!$ok) { | ||
| trigger_error( | ||
| sprintf('Failed to set permissions on %s file: %s', $context, xoops_file_label($path)), | ||
| E_USER_WARNING | ||
| ); | ||
| } | ||
|
|
||
| return $ok; | ||
| } | ||
|
|
||
| /** | ||
| * Best-effort file removal used by atomic-write cleanup paths and similar | ||
| * fire-and-forget cleanup. Skips non-existent paths so already-deleted | ||
| * files don't trigger warnings, suppresses the unlink() warning via a | ||
| * scoped error_reporting() toggle (no `@` operator), and re-checks | ||
| * existence after a failed unlink — only logging when the file is still | ||
| * present, so TOCTOU races resolve silently. | ||
| * | ||
| * @param string $path Absolute path to the file to remove. | ||
| * @param string $context Short label used in the warning message | ||
| * (e.g. 'temporary', 'backup'). | ||
| * | ||
| * @return void | ||
| */ | ||
| function xoops_remove_file_quietly($path, $context = 'temporary') | ||
| { | ||
| // file_exists() returns false for broken symlinks, so a dangling | ||
| // symlink would be skipped here and also bypass the post-unlink | ||
| // existence check below — leaving the orphaned link in place. Treat | ||
| // links as existing too: unlink() can remove broken symlinks just | ||
| // fine, and the targets they point to are not what we care about. | ||
| if (!file_exists($path) && !is_link($path)) { | ||
| return; | ||
| } | ||
| // Initialise $ok defensively — see xoops_chmod_quietly() for the | ||
| // rationale (error_reporting(0) does not disable user-defined | ||
| // error handlers). | ||
| $ok = false; | ||
| $previousLevel = error_reporting(0); | ||
| try { | ||
| $ok = unlink($path); | ||
| } finally { | ||
| error_reporting($previousLevel); | ||
| } | ||
| if (!$ok && (file_exists($path) || is_link($path))) { | ||
| trigger_error( | ||
| sprintf('Failed to remove %s file: %s', $context, xoops_file_label($path)), | ||
| E_USER_WARNING | ||
| ); | ||
| } | ||
| } |
| $avatarId = (int) ($myrow['avatar_id'] ?? 0); | ||
| $avatarFile = ltrim(str_replace('\\', '/', (string) ($myrow['avatar_file'] ?? '')), '/'); | ||
| if ('' === $avatarFile) { | ||
| if (!$this->db->exec('DELETE FROM ' . $this->db->prefix('avatar') . ' WHERE avatar_id=' . $avatarId)) { | ||
| $deleteOk = false; | ||
| } | ||
| continue; | ||
| } | ||
| $avatarCandidate = XOOPS_UPLOAD_PATH . '/' . $avatarFile; | ||
| $avatarParent = realpath(dirname($avatarCandidate)); | ||
| if ( | ||
| is_string($avatarParent) | ||
| && is_string($avatarRootPrefix) | ||
| && str_starts_with(rtrim($avatarParent, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR, $avatarRootPrefix) | ||
| && (is_file($avatarCandidate) || is_link($avatarCandidate)) | ||
| ) { | ||
| xoops_remove_file_quietly($avatarCandidate, 'orphaned avatar'); | ||
| } |
…ll-byte paths xoops_chmod_quietly() / xoops_remove_file_quietly(): wrap the fs syscall (and, in the remove helper, the file_exists()/is_link() probes) in catch(\Throwable) so a ValueError from a "\0"-bearing path — or a throwing user error handler — cannot abort the caller's unrelated work. CleanAvatar(): gate dirname/realpath/is_file/is_link/remove on "\0" not in normalized avatar_file; collapse the empty-string special branch into the same condition. Per-row and avatar_user_link DELETEs still run unconditionally so a malformed path never blocks reclaiming the orphaned row. Tests: add cleanAvatarHandlesNullByteAvatarFileAndStillDeletesDbRow, covering the ValueError-skipped path with both DELETEs asserted.
|
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@htdocs/include/cp_functions.php`:
- Around line 159-163: The trigger_error calls currently expose root-relative
paths by using xoops_file_label($path); change those warning messages to include
only the basename of the path (use basename($path) or an equivalent safe helper)
instead of xoops_file_label($path) in the trigger_error calls (the ones invoking
trigger_error(sprintf('Failed to set permissions on %s file: %s', $context,
xoops_file_label($path)), E_USER_WARNING) and the similar block at the other
occurrence), so non-fatal filesystem warnings log only the filename and not the
full install layout.
In `@tests/unit/htdocs/modules/system/SystemMaintenanceTest.php`:
- Around line 366-592: The tests exercise filesystem branches but never assert
the new boolean return from CleanAvatar(); update each existing test that calls
$maintenance->CleanAvatar() to assert the returned bool (e.g.
$this->assertTrue($maintenance->CleanAvatar(), ...)) and add one new unit test
that uses createMockDatabase()/stubAvatarSweep() and sets
$db->expects(...)->method('exec')->willReturnOnConsecutiveCalls(true, false) (or
otherwise simulate a DELETE failure) then
assertFalse($maintenance->CleanAvatar()) to cover the failing-exec path;
reference the CleanAvatar() call, createMockDatabase(), stubAvatarSweep(), and
the mock exec() expectations to locate where to add these assertions.
- Around line 366-592: Rename each PHPUnit test method to the test... convention
so PHPUnit 9.6 discovers them: change
cleanAvatarRemovesValidAvatarFileUnderUploadRoot() →
testCleanAvatarRemovesValidAvatarFileUnderUploadRoot(),
cleanAvatarSkipsTraversalPathButStillDeletesDbRow() →
testCleanAvatarSkipsTraversalPathButStillDeletesDbRow(),
cleanAvatarSkipsAbsolutePathButStillDeletesDbRow() →
testCleanAvatarSkipsAbsolutePathButStillDeletesDbRow(),
cleanAvatarHandlesMissingFileAndStillDeletesDbRow() →
testCleanAvatarHandlesMissingFileAndStillDeletesDbRow(),
cleanAvatarHandlesEmptyAvatarFileAndStillDeletesDbRow() →
testCleanAvatarHandlesEmptyAvatarFileAndStillDeletesDbRow(),
cleanAvatarHandlesNullByteAvatarFileAndStillDeletesDbRow() →
testCleanAvatarHandlesNullByteAvatarFileAndStillDeletesDbRow(),
cleanAvatarSkipsNonAvatarsSubdirUnderUploadRoot() →
testCleanAvatarSkipsNonAvatarsSubdirUnderUploadRoot(), and
cleanAvatarNormalisesBackslashesInAvatarFile() →
testCleanAvatarNormalisesBackslashesInAvatarFile(); keep the existing #[Test]
attributes or remove them if you prefer, but the critical change is renaming the
methods so PHPUnit 9.6 recognizes the tests.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: fc364852-5811-4398-a9fd-1bae3f576321
📒 Files selected for processing (3)
htdocs/include/cp_functions.phphtdocs/modules/system/class/maintenance.phptests/unit/htdocs/modules/system/SystemMaintenanceTest.php
| if (!$ok) { | ||
| trigger_error( | ||
| sprintf('Failed to set permissions on %s file: %s', $context, xoops_file_label($path)), | ||
| E_USER_WARNING | ||
| ); |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win
Use basename-only labels in these warnings.
These helper warnings still surface root-relative paths via xoops_file_label(). The repo rule for non-fatal filesystem warnings is stricter: log only the basename so cleanup failures do not disclose install layout.
As per coding guidelines: "Use trigger_error() with E_USER_WARNING for non-fatal failures. Use basename() in error messages to avoid exposing server paths."
Also applies to: 223-226
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@htdocs/include/cp_functions.php` around lines 159 - 163, The trigger_error
calls currently expose root-relative paths by using xoops_file_label($path);
change those warning messages to include only the basename of the path (use
basename($path) or an equivalent safe helper) instead of xoops_file_label($path)
in the trigger_error calls (the ones invoking trigger_error(sprintf('Failed to
set permissions on %s file: %s', $context, xoops_file_label($path)),
E_USER_WARNING) and the similar block at the other occurrence), so non-fatal
filesystem warnings log only the filename and not the full install layout.
| #[Test] | ||
| public function cleanAvatarRemovesValidAvatarFileUnderUploadRoot(): void | ||
| { | ||
| $scratchRel = $this->avatarScratchRel(); | ||
| [$rel, $abs] = $this->placeFixtureAvatar($scratchRel, 'foo.png'); | ||
|
|
||
| $db = $this->createMockDatabase(); | ||
| $this->stubAvatarSweep($db, [ | ||
| ['avatar_id' => 42, 'avatar_file' => $rel], | ||
| ]); | ||
| // exec() called twice: once for DELETE FROM avatar, once for the | ||
| // avatar_user_link cleanup at the end of CleanAvatar(). | ||
| $db->expects($this->exactly(2))->method('exec')->willReturn(true); | ||
|
|
||
| $maintenance = $this->createMaintenance($db); | ||
| try { | ||
| $maintenance->CleanAvatar(); | ||
| $this->assertFileDoesNotExist($abs, 'fixture avatar should have been removed'); | ||
| } finally { | ||
| $this->removeScratchDir($scratchRel); | ||
| } | ||
| } | ||
|
|
||
| #[Test] | ||
| public function cleanAvatarSkipsTraversalPathButStillDeletesDbRow(): void | ||
| { | ||
| // To meaningfully exercise the upload-root containment check the | ||
| // fixture has to live at the location the traversal string | ||
| // ACTUALLY resolves to. dirname(realpath(XOOPS_UPLOAD_PATH)) is | ||
| // the parent of the upload root, so a fixture placed there is | ||
| // reachable via '../<basename>' relative to XOOPS_UPLOAD_PATH. | ||
| // Result: | ||
| // - realpath() succeeds (the file exists at the parent dir) | ||
| // - the prefix check rejects it (not under uploads/) | ||
| // - the fixture survives — exercising the containment branch | ||
| // Without this setup, realpath() would just return false on a | ||
| // non-existent target and the test would pass for the wrong | ||
| // reason. | ||
| $uploadRoot = realpath(XOOPS_UPLOAD_PATH); | ||
| $this->assertIsString($uploadRoot, 'XOOPS_UPLOAD_PATH must resolve via realpath()'); | ||
| $outside = dirname($uploadRoot) . DIRECTORY_SEPARATOR . 'xoops_avatar_traversal_target_' . uniqid() . '.png'; | ||
| $bytesWritten = file_put_contents($outside, 'must-not-be-removed'); | ||
| $this->assertNotFalse($bytesWritten, 'Could not write traversal fixture: ' . $outside); | ||
| $this->assertSame(strlen('must-not-be-removed'), $bytesWritten); | ||
| $traversalRel = '../' . basename($outside); | ||
|
|
||
| $db = $this->createMockDatabase(); | ||
| $this->stubAvatarSweep($db, [ | ||
| ['avatar_id' => 99, 'avatar_file' => $traversalRel], | ||
| ]); | ||
| // DB row + avatar_user_link cleanup still execute. | ||
| $db->expects($this->exactly(2))->method('exec')->willReturn(true); | ||
|
|
||
| $maintenance = $this->createMaintenance($db); | ||
| try { | ||
| // Sanity: realpath of the traversal path resolves to the | ||
| // fixture (i.e. realpath() succeeds) — proving this test | ||
| // really exercises the prefix-check branch and not the | ||
| // realpath()-returns-false short-circuit. | ||
| $resolved = realpath(XOOPS_UPLOAD_PATH . '/' . $traversalRel); | ||
| $this->assertSame(realpath($outside), $resolved, 'traversal must actually resolve to the fixture'); | ||
|
|
||
| $maintenance->CleanAvatar(); | ||
| $this->assertFileExists($outside, 'traversal target outside upload root must not be removed'); | ||
| } finally { | ||
| @unlink($outside); | ||
| } | ||
| } | ||
|
|
||
| #[Test] | ||
| public function cleanAvatarSkipsAbsolutePathButStillDeletesDbRow(): void | ||
| { | ||
| // An absolute path stored in avatar_file should not allow the | ||
| // cleanup to escape XOOPS_UPLOAD_PATH. Use a temp fixture rather | ||
| // than hard-coding /etc/hosts so the test runs on every OS | ||
| // (Windows CI doesn't have /etc/hosts at the same location). | ||
| // The ltrim('/') step turns '/abs/path/foo.png' into | ||
| // 'abs/path/foo.png' which is then resolved relative to the | ||
| // upload root — typically to a non-existent path, so realpath() | ||
| // returns false and the cleanup is skipped. | ||
| $outside = tempnam(sys_get_temp_dir(), 'xoops_avatar_absolute_'); | ||
| $this->assertNotFalse($outside, 'tempnam should succeed'); | ||
| $bytesWritten = file_put_contents($outside, 'must-not-be-removed'); | ||
| $this->assertNotFalse($bytesWritten, 'Could not write absolute-path fixture: ' . $outside); | ||
| $this->assertSame(strlen('must-not-be-removed'), $bytesWritten); | ||
|
|
||
| $db = $this->createMockDatabase(); | ||
| $this->stubAvatarSweep($db, [ | ||
| ['avatar_id' => 100, 'avatar_file' => $outside], | ||
| ]); | ||
| $db->expects($this->exactly(2))->method('exec')->willReturn(true); | ||
|
|
||
| $maintenance = $this->createMaintenance($db); | ||
| try { | ||
| $maintenance->CleanAvatar(); | ||
| $this->assertFileExists($outside, 'absolute-path fixture must not be removed by avatar cleanup'); | ||
| } finally { | ||
| @unlink($outside); | ||
| } | ||
| } | ||
|
|
||
| #[Test] | ||
| public function cleanAvatarHandlesMissingFileAndStillDeletesDbRow(): void | ||
| { | ||
| $db = $this->createMockDatabase(); | ||
| $this->stubAvatarSweep($db, [ | ||
| // File reference that does not exist on disk — realpath() will | ||
| // return false. The DB row cleanup must still run. | ||
| ['avatar_id' => 200, 'avatar_file' => 'avatars/nonexistent_' . uniqid() . '.png'], | ||
| ]); | ||
| $db->expects($this->exactly(2))->method('exec')->willReturn(true); | ||
|
|
||
| $maintenance = $this->createMaintenance($db); | ||
| $maintenance->CleanAvatar(); // assertion is the exec() call count | ||
| } | ||
|
|
||
| #[Test] | ||
| public function cleanAvatarHandlesEmptyAvatarFileAndStillDeletesDbRow(): void | ||
| { | ||
| $db = $this->createMockDatabase(); | ||
| $this->stubAvatarSweep($db, [ | ||
| ['avatar_id' => 300, 'avatar_file' => ''], | ||
| ]); | ||
| $db->expects($this->exactly(2))->method('exec')->willReturn(true); | ||
|
|
||
| $maintenance = $this->createMaintenance($db); | ||
| $maintenance->CleanAvatar(); | ||
| } | ||
|
|
||
| #[Test] | ||
| public function cleanAvatarHandlesNullByteAvatarFileAndStillDeletesDbRow(): void | ||
| { | ||
| // On PHP 8+ dirname()/realpath()/is_file()/is_link() raise | ||
| // ValueError when the argument contains "\0". CleanAvatar() must | ||
| // skip the filesystem work for such a row but still issue both | ||
| // DELETEs — the per-row avatar DELETE and the trailing | ||
| // avatar_user_link cleanup — so the malformed row doesn't block | ||
| // the rest of the sweep. | ||
| $db = $this->createMockDatabase(); | ||
| $this->stubAvatarSweep($db, [ | ||
| ['avatar_id' => 400, 'avatar_file' => "avatars/evil\0.png"], | ||
| ]); | ||
| $db->expects($this->exactly(2))->method('exec')->willReturn(true); | ||
|
|
||
| $maintenance = $this->createMaintenance($db); | ||
| $maintenance->CleanAvatar(); | ||
| } | ||
|
|
||
| #[Test] | ||
| public function cleanAvatarSkipsNonAvatarsSubdirUnderUploadRoot(): void | ||
| { | ||
| // Defence-in-depth: even an avatar_file value that points to a | ||
| // legitimate path UNDER XOOPS_UPLOAD_PATH but OUTSIDE the | ||
| // avatars/ subtree must not be removed by the avatar sweep. | ||
| // Place a fixture at uploads/files/<unique>.doc, set avatar_file | ||
| // to that path, verify the file survives. | ||
| $filesDir = XOOPS_UPLOAD_PATH . '/files'; | ||
| $created = false; | ||
| if (!is_dir($filesDir)) { | ||
| if (!mkdir($filesDir, 0755, true) && !is_dir($filesDir)) { | ||
| $this->fail('Could not create test files dir: ' . $filesDir); | ||
| } | ||
| $created = true; | ||
| } | ||
| $fixtureName = '_test_nonavatar_' . getmypid() . '_' . uniqid() . '.doc'; | ||
| $fixturePath = $filesDir . '/' . $fixtureName; | ||
| $bytesWritten = file_put_contents($fixturePath, 'must-not-be-removed'); | ||
| $this->assertNotFalse($bytesWritten, 'Could not write non-avatar fixture: ' . $fixturePath); | ||
| $this->assertSame(strlen('must-not-be-removed'), $bytesWritten); | ||
| $rel = 'files/' . $fixtureName; | ||
|
|
||
| $db = $this->createMockDatabase(); | ||
| $this->stubAvatarSweep($db, [ | ||
| ['avatar_id' => 500, 'avatar_file' => $rel], | ||
| ]); | ||
| $db->expects($this->exactly(2))->method('exec')->willReturn(true); | ||
|
|
||
| $maintenance = $this->createMaintenance($db); | ||
| try { | ||
| // Sanity: the resolved path IS under uploads/ but NOT under | ||
| // uploads/avatars/, so the broad upload-root check would | ||
| // have allowed deletion. Asserting the resolution succeeds | ||
| // proves the test really exercises the narrow prefix branch. | ||
| $resolved = realpath($fixturePath); | ||
| $this->assertIsString($resolved, 'fixture should resolve'); | ||
| $this->assertStringStartsWith( | ||
| rtrim((string) realpath(XOOPS_UPLOAD_PATH), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR, | ||
| $resolved, | ||
| 'fixture must be inside uploads/' | ||
| ); | ||
|
|
||
| $maintenance->CleanAvatar(); | ||
| $this->assertFileExists($fixturePath, 'non-avatar file under uploads/ must not be removed'); | ||
| } finally { | ||
| @unlink($fixturePath); | ||
| if ($created) { | ||
| @rmdir($filesDir); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| #[Test] | ||
| public function cleanAvatarNormalisesBackslashesInAvatarFile(): void | ||
| { | ||
| // Windows-historic data may store 'avatars\foo.png'. The cleanup | ||
| // should normalise it and remove the file under XOOPS_UPLOAD_PATH. | ||
| $scratchRel = $this->avatarScratchRel(); | ||
| [$rel, $abs] = $this->placeFixtureAvatar($scratchRel, 'win.png'); | ||
| // Replace forward slashes with backslashes only in the segment | ||
| // separator, NOT inside the scratch directory name (it has '_' | ||
| // not '\\'). This is what a Windows-saved row looked like. | ||
| $winRel = str_replace('/', '\\', $rel); | ||
|
|
||
| $db = $this->createMockDatabase(); | ||
| $this->stubAvatarSweep($db, [ | ||
| ['avatar_id' => 400, 'avatar_file' => $winRel], | ||
| ]); | ||
| $db->expects($this->exactly(2))->method('exec')->willReturn(true); | ||
|
|
||
| $maintenance = $this->createMaintenance($db); | ||
| try { | ||
| $maintenance->CleanAvatar(); | ||
| $this->assertFileDoesNotExist($abs, 'backslash-normalised avatar should be removed'); | ||
| } finally { | ||
| $this->removeScratchDir($scratchRel); | ||
| } | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial | ⚡ Quick win
Add an assertion for CleanAvatar()’s new boolean contract.
This block exercises the filesystem branches well, but it never checks the new bool return that now signals DELETE failures. A single failing-exec() case would lock in the PR’s main behavioral change.
➕ Suggested coverage
+ public function testCleanAvatarReturnsFalseWhenAvatarDeleteFails(): void
+ {
+ $db = $this->createMockDatabase();
+ $this->stubAvatarSweep($db, [
+ ['avatar_id' => 42, 'avatar_file' => ''],
+ ]);
+ $db->expects($this->exactly(2))
+ ->method('exec')
+ ->willReturnOnConsecutiveCalls(false, true);
+
+ $maintenance = $this->createMaintenance($db);
+ $this->assertFalse($maintenance->CleanAvatar());
+ }🧰 Tools
🪛 PHPMD (2.15.0)
[warning] 372-372: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)
(ShortVariable)
[error] 390-433: Remove error control operator '@' on line 431. (undefined)
(ErrorControlOperator)
[warning] 412-412: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)
(ShortVariable)
[error] 436-465: Remove error control operator '@' on line 463. (undefined)
(ErrorControlOperator)
[warning] 452-452: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)
(ShortVariable)
[warning] 470-470: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)
(ShortVariable)
[warning] 485-485: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)
(ShortVariable)
[warning] 504-504: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)
(ShortVariable)
[error] 515-565: Remove error control operator '@' on line 560. (undefined)
(ErrorControlOperator)
[error] 515-565: Remove error control operator '@' on line 562. (undefined)
(ErrorControlOperator)
[warning] 537-537: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)
(ShortVariable)
[warning] 579-579: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)
(ShortVariable)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tests/unit/htdocs/modules/system/SystemMaintenanceTest.php` around lines 366
- 592, The tests exercise filesystem branches but never assert the new boolean
return from CleanAvatar(); update each existing test that calls
$maintenance->CleanAvatar() to assert the returned bool (e.g.
$this->assertTrue($maintenance->CleanAvatar(), ...)) and add one new unit test
that uses createMockDatabase()/stubAvatarSweep() and sets
$db->expects(...)->method('exec')->willReturnOnConsecutiveCalls(true, false) (or
otherwise simulate a DELETE failure) then
assertFalse($maintenance->CleanAvatar()) to cover the failing-exec path;
reference the CleanAvatar() call, createMockDatabase(), stubAvatarSweep(), and
the mock exec() expectations to locate where to add these assertions.
🧩 Analysis chain
🌐 Web query:
Does PHPUnit 9.6 support the PHPUnit\Framework\Attributes\TestandCoversClassattributes, or should tests keeptest... method names / annotations for cross-version compatibility?
💡 Result:
No, PHPUnit 9.6 does not support the PHPUnit\Framework\Attributes\Test or CoversClass attributes. These PHP 8 attributes were introduced in PHPUnit 10.[1][2][3] The official PHPUnit 9.6 manual documents only annotations (e.g., @test, @covers, @coversClass) for marking test methods and code coverage, with no mention of attributes.[4][5][6] PHPUnit 9.6 changelogs also lack any reference to attribute support.[7][8][9] PHPUnit 10 explicitly adds support for attributes as an alternative to annotations, checking attributes first and falling back to annotations if absent.[1][3][10] For cross-version compatibility (especially with PHPUnit 9.x), tests should continue using test... method name prefixes or annotation equivalents like @test and @coversClass / @coversDefaultClass. Attributes will cause errors in 9.6, as the PHPUnit\Framework\Attributes classes do not exist there.[11][12]
Citations:
- 1: https://phpunit.de/announcements/phpunit-10.html
- 2: Support PHP 8 attributes for adding metadata to test classes and test methods as well as tested code units sebastianbergmann/phpunit#4502
- 3: https://docs.phpunit.de/en/11.0/attributes.html
- 4: https://docs.phpunit.de/en/9.6/
- 5: https://docs.phpunit.de/en/9.6/code-coverage-analysis.html
- 6: https://docs.phpunit.de/en/9.6/annotations.html?highlight=coversDefaultClass
- 7: https://github.com/sebastianbergmann/phpunit/blob/9.6/ChangeLog-9.6.md
- 8: https://github.com/sebastianbergmann/phpunit/blob/9.6.15/ChangeLog-9.6.md
- 9: https://github.com/sebastianbergmann/phpunit/blob/9.6.32/ChangeLog-9.6.md
- 10: https://docs.phpunit.de/en/11.1/annotations.html
- 11: Attribute class "PHPUnit\Framework\Attributes\Test" not found sebastianbergmann/phpunit#5737
- 12: https://github.com/sebastianbergmann/phpunit/blob/9.6.2/src/Framework/Assert.php
Rename all test methods to the test... convention for PHPUnit 9.6 compatibility.
These new test methods use #[Test] attributes, which are incompatible with PHPUnit 9.6. The PHPUnit\Framework\Attributes classes were introduced in PHPUnit 10 and do not exist in 9.6, causing test discovery and execution to fail. Rename each method to follow test{MethodName} (e.g., testCleanAvatarRemovesValidAvatarFileUnderUploadRoot()) to maintain compatibility across the required 9.6–11.x range per coding guidelines.
🧰 Tools
🪛 PHPMD (2.15.0)
[warning] 372-372: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)
(ShortVariable)
[error] 390-433: Remove error control operator '@' on line 431. (undefined)
(ErrorControlOperator)
[warning] 412-412: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)
(ShortVariable)
[error] 436-465: Remove error control operator '@' on line 463. (undefined)
(ErrorControlOperator)
[warning] 452-452: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)
(ShortVariable)
[warning] 470-470: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)
(ShortVariable)
[warning] 485-485: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)
(ShortVariable)
[warning] 504-504: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)
(ShortVariable)
[error] 515-565: Remove error control operator '@' on line 560. (undefined)
(ErrorControlOperator)
[error] 515-565: Remove error control operator '@' on line 562. (undefined)
(ErrorControlOperator)
[warning] 537-537: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)
(ShortVariable)
[warning] 579-579: Avoid variables with short names like $db. Configured minimum length is 3. (undefined)
(ShortVariable)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tests/unit/htdocs/modules/system/SystemMaintenanceTest.php` around lines 366
- 592, Rename each PHPUnit test method to the test... convention so PHPUnit 9.6
discovers them: change cleanAvatarRemovesValidAvatarFileUnderUploadRoot() →
testCleanAvatarRemovesValidAvatarFileUnderUploadRoot(),
cleanAvatarSkipsTraversalPathButStillDeletesDbRow() →
testCleanAvatarSkipsTraversalPathButStillDeletesDbRow(),
cleanAvatarSkipsAbsolutePathButStillDeletesDbRow() →
testCleanAvatarSkipsAbsolutePathButStillDeletesDbRow(),
cleanAvatarHandlesMissingFileAndStillDeletesDbRow() →
testCleanAvatarHandlesMissingFileAndStillDeletesDbRow(),
cleanAvatarHandlesEmptyAvatarFileAndStillDeletesDbRow() →
testCleanAvatarHandlesEmptyAvatarFileAndStillDeletesDbRow(),
cleanAvatarHandlesNullByteAvatarFileAndStillDeletesDbRow() →
testCleanAvatarHandlesNullByteAvatarFileAndStillDeletesDbRow(),
cleanAvatarSkipsNonAvatarsSubdirUnderUploadRoot() →
testCleanAvatarSkipsNonAvatarsSubdirUnderUploadRoot(), and
cleanAvatarNormalisesBackslashesInAvatarFile() →
testCleanAvatarNormalisesBackslashesInAvatarFile(); keep the existing #[Test]
attributes or remove them if you prefer, but the critical change is renaming the
methods so PHPUnit 9.6 recognizes the tests.
| // execute outside a bootstrapped XOOPS context. Without this, the | ||
| // require_once below would fail with an "undefined constant" fatal | ||
| // when the file is hit via a direct URL, leaking server path details | ||
| // in the error message. Uses the project-standard one-liner shape | ||
| // for consistency with the rest of the codebase. | ||
| defined('XOOPS_ROOT_PATH') || exit('Restricted access'); | ||
|
|
||
| // xoops_remove_file_quietly() lives in cp_functions.php; admin and install | ||
| // callers normally load it via cp_header.php / page_moduleinstaller.php, | ||
| // but require it explicitly here so SystemMaintenance is self-sufficient | ||
| // regardless of which context instantiates it. | ||
| require_once XOOPS_ROOT_PATH . '/include/cp_functions.php'; | ||
|
|
| /** | ||
| * Recursively remove the per-test scratch directory. | ||
| */ | ||
| private function removeScratchDir(string $scratchRel): void | ||
| { |



Changes:
cp_functions.php:
+ xoops_remove_file_quietly($path, $context): new helper for best-effort file removal. Skips non-existent paths, suppresses the unlink warning via
a SCOPED error_reporting() toggle (no @ operator) wrapped in try/finally, and re-checks file_exists() after a failed unlink — only logging when the
file is still present so TOCTOU races resolve silently. Uses xoops_file_label() for non-sensitive path labels in warnings.
- 9 @Unlink cleanup sites replaced with the helper.
- @chmod($tempFile, $perms) replaced with checked chmod() that logs on failure but continues (content is already written).
modules/system/class/maintenance.php:
+ Explicit
require_once .../include/cp_functions.phpso SystemMaintenance is self-sufficient regardless of which caller (admin, install,modulesadmin, preferences) loads it.
- 6 atomic-write @Unlink cleanup sites replaced with the helper.
- @chmod replaced with checked + warn (mirrors cp_functions.php).
- cleanOrphanedAvatars(): @Unlink replaced with the helper plus a path-traversal-safe resolution. Avatar rows store 'avatars/'
(kernel/avatar.php and 14 admin/profile writers), so basename() would have stripped the directory and silently bypassed all orphan cleanup. The new form
normalises backslashes, strips leading slashes, resolves via realpath() with upload-root containment, and confirms is_file() before removal. The DB row
cleanup runs unconditionally.
Intentionally retained:
- The 7 @rename(...) calls inside
if (!...)checks. These are the core atomic-move operations; the boolean return is already detected and reportedvia trigger_error(). Removing the @ would let PHP's native warning fire alongside our diagnostic, double-reporting one event into display_errors
output. Comment block in each file documents the rationale.
Summary by CodeRabbit
Bug Fixes
Tests